fix(lockfile): diagnose merge conflicts without automatic recovery - #3028
Lachlan Heywood (lachieh) wants to merge 2 commits into
Conversation
There was a problem hiding this comment.
🟡 Changes recommended
Resolve the remaining lockfile path, decoding, frozen-guidance, and documentation issues.
Get a fresh assessment by requesting another Copilot review.
Pull request overview
Adds conflict-marker detection for lockfiles, regenerating them during full installs while failing closed for frozen and partial operations.
Changes:
- Updates install, lock, MCP, dry-run, and frozen-mode handling.
- Adds unit and integration coverage.
- Updates CLI documentation and changelog.
File summaries
| File | Description |
|---|---|
tests/unit/install/test_frozen.py |
Tests frozen conflict behavior. |
tests/unit/deps/test_lockfile_conflict_markers.py |
Tests marker detection and discard behavior. |
tests/integration/test_install_conflicted_lockfile_e2e.py |
Covers CLI recovery and failure modes. |
src/apm_cli/install/service.py |
Adds frozen-mode failure handling. |
src/apm_cli/install/presentation/dry_run.py |
Reports conflicts without modifying files. |
src/apm_cli/install/mcp/command.py |
Surfaces MCP lockfile errors. |
src/apm_cli/install/errors.py |
Refines frozen recovery guidance. |
src/apm_cli/deps/lockfile.py |
Detects and discards conflicted lockfiles. |
src/apm_cli/commands/lock.py |
Applies recovery and export handling. |
src/apm_cli/commands/install.py |
Regenerates conflicted files during full installs. |
docs/src/content/docs/troubleshooting/install-failures.md |
Documents recovery steps. |
docs/src/content/docs/reference/lockfile-spec.md |
Documents conflict semantics. |
docs/src/content/docs/reference/cli/lock.md |
Documents apm lock behavior. |
docs/src/content/docs/reference/cli/install.md |
Documents install and frozen behavior. |
CHANGELOG.md |
Records the fix. |
Review details
Suppressed comments (7)
CHANGELOG.md:29
- The
#2979suffix is the linked issue number, not this pull request's number. The changelog contract requires each entry to end with the actual PR number; replace this suffix with the PR number when it is known.
- A full `apm install` and `apm lock` now warn, discard `apm.lock.yaml`, and resolve from `apm.yml` when the lockfile still contains git merge conflict markers, instead of exiting with a YAML parse error. `apm install --frozen`, partial installs, and read-only commands such as `apm update` and `apm outdated` fail closed with an error that names the conflict and the next action, and the `--frozen` failure tip no longer points at commands that cannot read the lockfile. (#2979)
docs/src/content/docs/reference/cli/install.md:153
- The CLI behavior changed here, but the maintained
packages/apm-guide/.apm/skills/apm-usage/resources were not updated:commands.md:15still describes frozen mode only as missing/out-of-sync, andtroubleshooting.md:85-89has no merge-conflict recovery. Add the concise install/lock conflict behavior there so the package guidance does not give stale recovery instructions.
- **Frozen mode.** With `--frozen`, install resolves only what is in `apm.lock.yaml`. A missing lockfile, a direct dependency missing from it, or MCP config state that differs from `apm.yml` exits `1` before lockfile, target config, deployment, or cache mutation. Cold-cache installs (empty `apm_modules/`) with git `apm_package` deps are tolerated: MCP checks are skipped for absent package directories (the packages will be hydrated by the pipeline), and their MCP server configs are restored from the lockfile so no false drift is reported. Remote `claude_skill` dependencies declared at a repository root or subdirectory are also accepted from their locked type before materialization; once present, the lock type and detected skill shape must agree. Missing local paths still fail. A lockfile that contains git merge conflict markers also exits `1` and is never rewritten under `--frozen`. See [`config-consistency`](../../baseline-checks/#config-consistency) for the full manifest rule. Run normal `apm install` to create or repair MCP-only lock state, or to discard a conflicted lockfile and resolve from `apm.yml`, then retry frozen mode. Add-style invocations (`apm install PACKAGE` and `apm install --mcp NAME`) are rejected because they mutate `apm.yml`. Orphan package lock entries are tolerated; local-path deps are skipped. This is a structural check, not a content check -- run `apm audit --ci` for hash verification.
docs/src/content/docs/reference/lockfile-spec.md:398
- This "Every command" claim is broader than the current behavior:
commands/view.py::_lookup_lockfile_refandcommands/deps/cli.pycatchExceptionaroundLockFile.readand continue without lockfile metadata, so those readers still do not name this conflict. Narrow the sentence to commands that require the lockfile, or update those best-effort readers to surface the error.
conflict rather than a YAML error. Every command that reads the lockfile names
the file and the next action. A full `apm install` (no package arguments, no
src/apm_cli/commands/lock.py:313
apm lock exportis a read-only lockfile consumer, but this newLockFile.readcall still uses onlyget_lockfile_path. A project that has only the supported legacyapm.lockis therefore reported as having no lockfile, and a conflict in that file is never classified; route the path throughresolve_lockfile_path_for_read(project_root, read_only=True)as the other read-only consumers do (for example,commands/outdated.py:449).
lockfile = LockFile.read(lockfile_path)
src/apm_cli/deps/lockfile.py:1276
- This discard probe performs a second unguarded UTF-8 decode. A non-UTF-8 lockfile reaches it before the pipeline's
LockFile.read, so a full install reports a rawUnicodeDecodeErrorinstead of the normalized fail-closed lockfile error. Catch and normalize the decode here, leaving the file in place so it cannot be discarded as conflicted.
if not path.exists() or not has_conflict_markers(path.read_text(encoding="utf-8")):
return False
src/apm_cli/install/errors.py:103
- This new early return also applies to
FrozenInstallErrorfrom the generic unreadable-lockfile path inInstallService.enforce_frozen: that message only says--frozen could not read ...and does not tell the user how to recover. Preserve actionable guidance for unreadable (non-missing) lockfiles, or make that exception message include the normal-install repair action while keeping the missing-file case free of the obsoleteoutdatedtip.
if not error.reasons:
return ""
tests/integration/test_install_conflicted_lockfile_e2e.py:16
- This new integration module invokes the Click CLI in-process via
CliRunnerand touches a temporary filesystem, so it needs the module-levelcomponentbehavioral marker. Withoutpytestmark = pytest.mark.component, the new tests are left outside the repository's marker-only taxonomy and are not selected by component-scoped runs (seetests/quality/test_test_taxonomy.py:152-163).
import pytest
from click.testing import CliRunner
- Files reviewed: 15/15 changed files
- Comments generated: 1
- Review effort level: Lite
💡 Configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
|
Thank you for contributing this pull request. This PR is linked to #2979, which already carries maintainer status/accepted. Advisory triage recommendation is ready-for-review. That is not merge approval, not assignment, and not a request to run a review panel. CODEOWNERS already requested danielmeppiel and sergio-sisternes-epam. This note does not add or change review requests. A responsible human maintainer still needs to review the implementation against the accepted issue, including the chosen default: detect git conflict markers in the lockfile load owner, regenerate on a full non-frozen Generated by autopilot-pr-triage-worker. This comment is AI-generated and may contain errors. |
APM Review Panel:
|
| Persona | B | R | N | Takeaway |
|---|---|---|---|---|
| Python Architect | 0 | 2 | 1 | Conflicted-lockfile detect is centralized, but discard-vs-fail-closed and the unlink still fork outside InstallTransaction. |
| CLI Logging Expert | 0 | 0 | 0 | Conflict named with next action; empty frozen tip is intentional so outdated/update are not suggested. |
| DevX UX Expert | 0 | 2 | 1 | Tighten regenerate vs fail-closed defaults, dry-run exit codes for CI, and non-transactional lockfile updates. |
| Supply Chain Security Expert | 0 | 0 | 0 | Conflicted-lock regen is the existing missing-lockfile path; --frozen and non-conflict corruption stay fail-closed. No integrity bypass. |
| Doc Writer | 0 | 2 | 1 | Conflict matrix is in the right pages; keep recover vs fail-closed explicit, canonicalize in lockfile-spec, and drop unrelated commands.md hermes churn. |
| Test Coverage Expert | 1 | 2 | 0 | PR adds focused unit + integration tests but misses failed-install-after-discard, --force, and lifecycle unlink-then-fail snapshot. |
| Performance Expert | 0 | 0 | 0 | Single multiline regex in LockFile.read on small YAML; no measurable perf impact; regex precompiled at module scope. |
B = blocking-severity findings, R = recommended, N = nits.
Counts are signal strength, not gates. The maintainer ships.
Top 5 follow-ups
- [Test Coverage Expert] (blocking-severity) Add an integration fixture for failed install after conflicted-lock unlink/discard. -- Author-stated limitation: discard is outside the transaction, so durable state after unlink-then-fail is unguarded. git checkout recovers; the gap is a regression trap, not a new product switch.
- [Python Architect] Move conflicted-lock discard into InstallTransaction so a failed install can restore the prior file. -- Unlink today happens before resolve; later failure cannot roll the conflicted lock back through the existing transaction owner.
- [Doc Writer] Keep recover vs fail-closed explicit; do not claim every install rewrites a conflicted lock. -- Full install/lock recover; frozen/partial fail closed; dry-run warns and does not rewrite. Over-claim would fight the accepted default.
- [Doc Writer] Revert unrelated hermes/target wording churn in packages/apm-guide/.apm/skills/apm-usage/commands.md. -- A lockfile-conflict patch should not rewrite the hermes/target table; duplicate install rows already disagree.
- [Python Architect] Collapse discard vs fail-closed vs warn into one lockfile conflict policy used by install, lock, frozen, dry-run, and MCP. -- Detection is already centralized in LockFile.read; durable outcome is still recomputed at each call site.
Architecture
classDiagram
direction LR
class LockFile {
<<ValueObject>>
+read(path) LockFile
}
class has_conflict_markers {
<<Pure>>
}
class LockfileFormatError {
<<DomainError>>
}
class LockfileConflictError {
<<DomainError>>
+path Path
}
class discard_conflicted_lockfile {
<<IOBoundary>>
}
class InstallTransaction {
<<UnitOfWork>>
+commit(result) InstallResult
+fail(error) InstallResult
}
class InstallService {
+LockFile.read for frozen
}
class FrozenInstallError {
<<DomainError>>
}
LockfileConflictError --|> LockfileFormatError
LockFile ..> has_conflict_markers : detect
discard_conflicted_lockfile ..> has_conflict_markers : detect
LockFile ..> LockfileConflictError : raises
InstallService ..> LockFile : reads
InstallService ..> FrozenInstallError : raises
note for LockFile "Canonical detect: LockFile.read raises LockfileConflictError"
note for discard_conflicted_lockfile "Outcome fork: unlink on full install and apm lock"
note for InstallTransaction "Durable lockfile mutation belongs in this unit of work"
class LockFile:::touched
class LockfileConflictError:::touched
class discard_conflicted_lockfile:::touched
class InstallService:::touched
classDef touched fill:#fff3b0,stroke:#d47600
flowchart TD
installEntry["commands/install.py:install"] --> packages["_install_apm_packages"]
packages --> migrate["[FS] migrate_lockfile_if_needed"]
migrate --> fullGate{"full_install: not frozen and not packages and InstallMode.ALL"}
fullGate -->|yes| discard["[FS] lockfile.py:discard_conflicted_lockfile path.unlink"]
fullGate -->|no| laterRead["[I/O] LockFile.read"]
discard --> resolve["_install_apm_dependencies"]
laterRead --> conflict{"LockfileConflictError?"}
conflict -->|frozen InstallService| frozenErr["raise FrozenInstallError"]
conflict -->|partial add or --only| failClosed["fail closed; lockfile left in place"]
frozenErr --> txnFail["InstallTransaction.fail"]
lockEntry["commands/lock.py:_run_lock"] --> lockDiscard["[FS] discard_conflicted_lockfile"]
lockDiscard --> lockResolve["_install_apm_dependencies"]
mcpEntry["install/mcp/command.py:run_mcp_install"] --> mcpWrite["[FS] add_mcp_to_apm_yml"]
mcpWrite --> mcpRead["[I/O] LockFile.read during integration"]
mcpRead -->|LockfileFormatError| mcpClick["raise click.ClickException; apm.yml already written"]
dryEntry["presentation/dry_run.py:render_and_exit"] --> dryRead["[I/O] LockFile.read"]
dryRead -->|LockfileConflictError| dryWarn["logger.warning; treat lock as missing"]
Recommendation
CODEOWNERS should confirm detect-in-load, regenerate on full non-frozen install/lock, and fail-closed under --frozen and partial installs. Fold the docs precision pass (no every-install recover claim; revert commands.md hermes churn) if it is still cheap in this PR. Track unlink-then-fail coverage and transactional discard as follow-ups; leave --force, CI autodetection, and dry-run exit codes out of scope.
Full per-persona findings
Python Architect
- [recommended] Conflicted-lockfile outcome is split across call sites instead of one owner. at
src/apm_cli/commands/install.py:1837
Detection is centralized in LockFile.read; durable outcome (unlink vs fail closed vs warn) is recomputed at install, lock, frozen, dry-run, and MCP.
Suggested: One lockfile conflict policy invoked from those call sites. - [recommended] discard_conflicted_lockfile unlinks apm.lock.yaml outside InstallTransaction. at
src/apm_cli/deps/lockfile.py:1283
unlink before resolve; later install failure cannot restore the conflicted file via InstallTransaction. - [nit] MCP install catches LockfileFormatError after writing apm.yml. at
src/apm_cli/install/mcp/command.py:308
Pre-existing order; conflicted lock can leave manifest write without regenerated lock.
CLI Logging Expert
No findings.
DevX UX Expert
- [recommended] Make regenerate vs fail-closed explicit for interactive vs CI
The PR implements both regenerate-on-full-install and fail-closed for frozen/partial installs. Users and CI need a clear rule about which behavior is the default. CEO: accepted scope already covers this; do not add CI autodetection. - [recommended] Clarify dry-run output and ensure machine-detectable exit codes
Dry-run currently reports human-readable outcomes like "would make no changes" vs warnings. Author documented this limitation. CEO: keep warn + exit 0. - [nit] Address non-transactional lockfile/regeneration risks in UX and docs
Regenerating without atomic replace risks partial state if interrupted.
Supply Chain Security Expert
No findings.
Doc Writer
- [recommended] Do not over-claim that every install recovers a conflicted lockfile at
docs/src/content/docs/reference/cli/install.md
Full install/lock recover; frozen/partial fail closed; dry-run does not rewrite. - [recommended] Revert unrelated hermes/target wording churn in commands.md at
packages/apm-guide/.apm/skills/apm-usage/commands.md:15
A lockfile-conflict patch should not rewrite the hermes/target table; duplicate install rows already disagree. - [nit] State conflict semantics once; point other pages at lockfile-spec at
docs/src/content/docs/reference/lockfile-spec.md
Canonical definition belongs in lockfile-spec; recovery steps in install-failures.md.
Test Coverage Expert
- [blocking] No test that a failed install after discard/unlink leaves durable state safe
Author-stated limitation: discard outside transaction.
Proof (missing at):tests/integration/test_install_failed_after_discard_unlink.py - [recommended] No explicit --force override test
Product intent: --force is unchanged and is NOT a lock regenerate switch. CEO dropped this follow-up.
Proof (missing at):tests/integration/test_install_force_override.py - [recommended] No ApmLifecycle snapshot for unlink-then-fail
Overlaps the unlink-then-fail coverage gap.
Proof (missing at):tests/integration/test_lifecycle_unlink_then_fail_snapshot.py
Performance Expert
No findings.
This panel is advisory. It does not block merge. Re-apply the
panel-review label after addressing feedback to re-run.
Generated by autopilot-pr-review-worker. This comment is AI-generated and may contain errors.
|
Thank you Lachlan Heywood (@lachieh) I have enabled the merge queue for this PR. Please review the blocker actions from the APM Review Panel. Once the test coverage is fixed. Optionally, if you can take out the top 5 recommendations, that could help us reduce the technical debt. Thank you for your contribution. Sergio |
|
Thanks Sergio Sisternes (@sergio-sisternes-epam). The follow-ups are addressed in #3043. I left this PR as is so the merge queue can take it. I tried to stack on this branch, but cross-fork PRs can't target fork branches as the base so that branch will show 2 commits until this one merges. |
InstallTransaction now owns the conflicted-lockfile discard: it snapshots the bytes before unlinking and rollback puts the file back unless the attempt already wrote a new lockfile. apm lock runs under its own transaction so the same rule applies there. The module-level discard_conflicted_lockfile helper is removed. Follow-up to microsoft#3028 from the APM Review Panel.
Other lockfile format errors keep the redacted, verbose-only handling that path had before microsoft#3028 widened the except clause.
Head branch was pushed to by a user without write access
InstallTransaction now owns the conflicted-lockfile discard: it snapshots the bytes before unlinking and rollback puts the file back unless the attempt already wrote a new lockfile. apm lock runs under its own transaction so the same rule applies there. The module-level discard_conflicted_lockfile helper is removed. Follow-up to microsoft#3028 from the APM Review Panel.
50a59e5 to
4456bb6
Compare
Other lockfile format errors keep the redacted, verbose-only handling that path had before microsoft#3028 widened the except clause.
InstallTransaction now owns the conflicted-lockfile discard: it snapshots the bytes before unlinking and rollback puts the file back unless the attempt already wrote a new lockfile. apm lock runs under its own transaction so the same rule applies there. The module-level discard_conflicted_lockfile helper is removed. Follow-up to microsoft#3028 from the APM Review Panel.
|
Rebased onto |
InstallTransaction now owns the conflicted-lockfile discard: it snapshots the bytes before unlinking and rollback puts the file back unless the attempt already wrote a new lockfile. apm lock runs under its own transaction so the same rule applies there. The module-level discard_conflicted_lockfile helper is removed. Follow-up to microsoft#3028 from the APM Review Panel.
|
Sergio Sisternes (@sergio-sisternes-epam) the APM Review Panel's blocking item is now fixed in this PR rather than a stacked follow-up, so there is nothing left to merge separately. 168fcb1 moves the conflicted-lockfile discard into Follow-up 3 (docs precision) is in the docs commits. On follow-up 4, the a9f5590 registers the decision as an architecture owner which is follow-up 5. Per the panel's own dissent I left |
Other lockfile format errors keep the redacted, verbose-only handling that path had before microsoft#3028 widened the except clause.
InstallTransaction now owns the conflicted-lockfile discard: it snapshots the bytes before unlinking and rollback puts the file back unless the attempt already wrote a new lockfile. apm lock runs under its own transaction so the same rule applies there. The module-level discard_conflicted_lockfile helper is removed. Follow-up to microsoft#3028 from the APM Review Panel.
7943869 to
a9f5590
Compare
|
Lachlan Heywood (@lachieh) Spec Conformance CI check failed. Please submit a quick fix so I can approve and close the PR. Merge queue is engaged, by your change will require a new approval from my side. Thank you! |
Other lockfile format errors keep the redacted, verbose-only handling that path had before microsoft#3028 widened the except clause.
InstallTransaction now owns the conflicted-lockfile discard: it snapshots the bytes before unlinking and rollback puts the file back unless the attempt already wrote a new lockfile. apm lock runs under its own transaction so the same rule applies there. The module-level discard_conflicted_lockfile helper is removed. Follow-up to microsoft#3028 from the APM Review Panel.
Head branch was pushed to by a user without write access
a9f5590 to
c3e534e
Compare
|
Sergio Sisternes (@sergio-sisternes-epam) fixed in The gate was the Mode B silent-extension detector, not a test failure. A waiver would have been the wrong call: conflicted-lockfile recovery is observable behaviour under critical paths, so my earlier "N/A -- does not change OpenAPM-observable behaviour" was wrong. Added the citation instead.
A lockfile unreadable for any other reason is explicitly out of scope and keeps failing closed. Full ritual in the same commit: anchor + prose, Appendix C row, Section 5.7 and 11.3.2 enumerations, statement counts (123 -> 124, 119 MUST), revision history 0.1.42, the manifest entry, three Locally: Understood that this needs a fresh approval from you. |
Daniel Meppiel (danielmeppiel)
left a comment
There was a problem hiding this comment.
Lachlan Heywood (@lachieh), thank you for the investigation and the work on the recovery safeguards. I am narrowing the scope of this PR: ship the diagnostic bug fixes here; defer automatic recovery as a separate feature/design decision.
The approved diagnostic-only scope is recorded on #2979:
#2979 (comment)
Please revise this PR as follows:
- Keep conflict detection and clear errors. Use the existing canonical lockfile-load owner to identify Git conflict markers and name the affected file and cause. Preserve existing best-effort readers and redaction; do not expose unrelated raw parser content.
- Fix the unusable repair advice. For an unreadable/conflicted lockfile, tell the user to resolve the conflict or restore a known-good lockfile before retrying. Do not recommend
apm outdated,apm update, or a non-frozen install as though those commands can repair the same unreadable file. - Remove automatic recovery from this PR. Full install and
apm lockmust not discard or regenerate the conflicted file. Frozen, partial and preview paths must also preserve it. Remove recovery-only discard/restore machinery, its newly introduced architecture-owner/guard additions, and tests that exist solely to authorize that recovery; preserve unrelated existing transaction behavior and guards. - Keep the specification honest and equally narrow. Remove recovery-authorizing clauses from the proposed
req-lk-023change and corresponding recovery-only manifest, count and generated conformance changes. Retain applicable diagnostic conformance evidence under existing requirements where appropriate. If the diagnostic-only change genuinely needs a new normative amendment, bring that narrow amendment back for agreement rather than retaining the automatic-recovery contract or bypassing the conformance check with a waiver. - Prove and document the diagnostic-only contract. Cover conflict recognition without false positives, actionable messages, byte-for-byte lockfile preservation across the affected commands, unchanged handling of other corruption, and the preserved offline/frozen/security/exit behavior. Update the relevant docs, apm-guide resources, changelog and PR description; the current implementation/test prose still describes the wider draft.
The original automatic-recovery request remains open as a deferred type/feature requiring design. This PR remains an accepted type/bug only for the bounded diagnostic slice. I changed its closing reference to an ordinary issue reference so it will not close #2979.
For the future design discussion, npm's conflict-aware JSON merging is not equivalent to throwing away APM's lockfile and resolving again, particularly when exact pins and deployment records can be lost. No automatic merging, regeneration, new flag, or change to --force is approved here.
Sergio Sisternes (@sergio-sisternes-epam), this is a new maintainer scope decision, not a claim that the contributor failed to follow the earlier review. Please preserve the existing review history; the revised diagnostic-only head will need renewed human review. I am requesting changes on the current head, not authorizing a merge or launching another implementation/review worker.
a853b0b to
c19ca66
Compare
|
Thanks, Daniel Meppiel (@danielmeppiel). Narrowed to the diagnostic slice. The conflicted lockfile is now preserved byte-for-byte on install, lock, frozen, partial and preview paths. One slight modification; Since it is going to be agents that are likely reading this message and resolving the issue the message names the repair as commands rather than just leaving resolution as a guess. Keeping a side preserves that branch's pins; the install reconciles only what the merge added. The git command resolves the file before any
Regarding this point; the diagnostic-only diff still trips Mode B (61 lines, threshold 20), and no existing requirement covers lockfile-load diagnostics; |
APM Review Panel:
|
| Persona | B | R | N | Takeaway |
|---|---|---|---|---|
| Python Architect | 0 | 1 | 0 | Frozen conflict handler forks canonical LockfileConflictError diagnostic; otherwise centralization to LockFile.read is architecturally sound. |
| DevX UX Expert | 0 | 2 | 0 | Git-only advice has applicability and path-targeting gaps; keep manual resolve/restore guidance tied to the diagnosed file. |
| Test Coverage Expert | n/a | n/a | n/a | Review unavailable: the synchronous task returned no JSON; no test-coverage conclusion or executed-test evidence is claimed. |
B = blocking-severity findings, R = recommended, N = nits.
Counts are signal strength, not gates. The maintainer ships. n/a denotes an unavailable review.
Top 3 follow-ups
- [DevX UX Expert] Add manual-edit or restore-known-good fallback for scenarios where git checkout --ours lacks a guaranteed repair path (committed markers, non-merge state). -- The human done-when requires an actionable next step for all readers. Committed or copied marker content triggers detection but the git-only recipe may restore the same markers without providing a working alternative.
- [DevX UX Expert] Use the diagnosed actual path rather than basename-only path.name for global and ancestor-export lockfile reads. -- Global (~/.apm) and ancestor-export lockfile paths differ from the caller working directory; the basename-relative git command may target the wrong file or fail outright.
- [Python Architect] Compose the frozen-specific framing around the canonical LockfileConflictError message rather than reconstructing it independently. -- Two message authorities for the same diagnostic risk silent divergence when the canonical wording or path derivation changes.
Recommendation
The diagnosis is correct and architecturally sound. The implementation targets the primary project-root merge-conflict scenario; that recipe was not executed in this review. However, the first two follow-ups above address scenarios where the printed recipe does not deliver the actionable next step the human scope requires -- they are not deferred polish. The maintainer should weigh whether those gaps are acceptable for the initial landing or warrant a pre-merge iteration. Human review and spec agreement remain pending independently of this advisory.
Full per-persona findings
Python Architect
- [recommended] Frozen handler in install/service.py hardcodes apm.lock.yaml and reconstructs the conflict diagnostic independently of LockfileConflictError, creating a second message authority. at
src/apm_cli/install/service.py
Single-owner rule (architecture.instructions.md): every durable decision has exactly one canonical owner; every call site routes through it. LockfileConflictError is the canonical owner of conflict diagnostic wording, path, and git-command format. The frozen handler at service.py:268-307 catches the typed error but discards its message, hardcodes the filename as 'apm.lock.yaml' instead of reading exc.path, and reconstructs the full prose ('--frozen cannot read apm.lock.yaml: it contains...') plus git commands independently. If the canonical error's wording, path.name derivation, or git command format changes, this handler silently diverges. Two authorities for one message is the pattern the single-owner rule exists to prevent. The frozen-specific prefix ('--frozen cannot read') and suffix ('then commit the result') are legitimate additions; they should compose around the canonical message, not replace it. This is not blocking because the hardcoded path is currently always correct for frozen installs (project_dir/apm.lock.yaml is the canonical lockfile location) and no correctness regression exists today -- the risk is silent divergence under future maintenance.
Design patterns -- Used in this PR: Base class + subclass (LockfileConflictError extending LockfileFormatError) for exception hierarchy; existing catch chains naturally specialize via ordered except clauses. Pragmatic suggestion: none -- the current shape is the simplest correct design at this scope; the fix is to compose the frozen prefix/suffix around str(exc) or exc.path, not to add a new pattern.
Suggested: Compose the frozen-specific framing around the canonical error rather than reconstructing it: use exc.path.name for the filename and reference str(exc) or the error's core message for the conflict diagnostic, adding only the frozen-specific prefix and 'then commit the result' suffix.
DevX UX Expert
-
[recommended] The Git-only recipe does not cover every file the marker detector diagnoses. at
src/apm_cli/deps/lockfile.py:84
Detection examines bytes, not Git index state. A copied/untracked conflict or a file whose committed contents already contain markers may have no usable merge side; checkout can fail or restore the same unreadable contents. The diagnostic supplies no manual-resolve or restore-known-good fallback. The current code was read, not executed in this review.
Suggested: Lead with resolving the named file or restoring a known-good copy. Keep any Git-side selection as an optional, context-qualified example, then retry only after the file is readable. -
[recommended] The printed basename can refer to a different lockfile than the diagnosed path. at
src/apm_cli/deps/lockfile.py:89
Lock export may find an ancestor manifest and outdated --global reads the user-scope lockfile without changing directory. The error names that actual path but prints git checkout apm.lock.yaml relative to the caller's directory. That recipe can fail or act on a different local file. An ancestor project may itself be a Git repository; the issue is path targeting, not an assumption that it is outside Git.
Suggested: Keep manual resolve/restore guidance tied to the actual diagnosed path; if showing a Git example, explicitly qualify the path and working directory.
Test Coverage Expert
Review unavailable: the synchronous task completed without a JSON return. No coverage or executed-test claim is inferred.
This panel is advisory. It does not block merge. Re-apply the panel-review label after addressing feedback to re-run.
Generated by autopilot-pr-review-worker. This comment is AI-generated and may contain errors.
APM Spec Guardian:
|
| Panel | Stance | Shocked | New B | New R | New N |
|---|---|---|---|---|---|
| Swagger / OpenAPI Editor | ship_with_followups | 8/10 | 0 | 1 | 2 |
| OCI Distribution Editor | ship_with_followups | 8/10 | 0 | 1 | 1 |
| Package-Manager Editor | ship_with_followups | 7/10 | 0 | 1 | 2 |
| TAG Architect | ship_with_followups | 8/10 | 0 | 2 | 1 |
B = new blocking findings, R = new recommended, N = new nits.
Counts are raw reviewer signals, not gates; the package-manager nit count includes one item not carried forward after source verification. The maintainer ships.
Convergent themes (flagged by 2+ panels)
- T1 -- Marker grammar underspecified: normative text references conflict markers without enumerating a minimum recognizable pattern set for interoperable conformance testing (supporting: sw-rec-r1-1, oci-rec-r1-1, pkg-rec-r1-1, tag-rec-r1-1)
- T2 -- Section 5.4 heading stale: title reads versions and bumping rules but now also houses conflict-marker load-time validation (req-lk-023) (supporting: sw-nit-r1-2, oci-nit-r1-1, pkg-nit-r1-1, tag-nit-r1-1)
Fold now (3 item(s))
-
[F1 / T1] req-lk-023 clause (a) -- After 'unresolved version-control merge conflict markers' in clause (a), insert an illustrative parenthetical: '(for example, lines beginning with seven consecutive less-than, greater-than, or pipe characters followed by a space or end-of-line)'. Immediately following the parenthetical, add a non-normative note: 'Note -- This parenthetical is an interim illustrative aid; the normative minimum marker grammar is not yet pinned by this specification.' This fold does NOT resolve the normative ambiguity and does NOT establish interoperable minimum grammar; it provides a concrete interim reference for implementers while the grammar decision (F4) remains open.
Success criterion:grep req-lk-023 clause (a) for the parenthetical text and for the 'interim illustrative aid' label; confirm no new req-XXX anchor or BCP 14 keyword is introduced by the addition. -
[F2 / T2] Section 5.4 heading -- Replace the Section 5.4 heading 'Lockfile versions (1, 2) and bumping rules' with 'Lockfile versions, bumping rules, and load-time validation'. Verify Appendix C table rows referencing section 5.4 cite the section number (not title text) and require no update.
Success criterion:grep for the new heading text in the spec body; verify the old heading no longer appears; confirm Appendix C row for req-lk-023 still cites section 5.4. -
[F3 / standalone] req-lk-023 clause (b) -- In the tail of clause (b), replace 'naming one as a follow-up step, sequenced after the resolving action, is permitted.' with 'naming one as a follow-up step, sequenced after the resolving action, MAY be included in the diagnostic.' This editorial substitution formalizes an already-granted permission using BCP 14 vocabulary per Section 2 conventions. It does NOT add a new normative statement, does NOT create a new req-XXX anchor, and does NOT increment the statement count; the MAY operates within the existing scope of req-lk-023.
Success criterion:grep req-lk-023 clause (b) for 'MAY be included in the diagnostic'; confirm statement count in Section 1.3, Appendix C trailer, and Appendix D 0.1.42 row remains 124 (119 MUST, 5 SHOULD).
Defer to v0.1.1
-
[F4 / T1] req-lk-023 clause (a) or new Section 3 terminology row -- After the maintainer confirms the explicit recognizable minimum marker grammar -- character count (7-character fixed or flexible), line-start anchoring, space-or-EOL delimiter, bare-separator exclusion, and literal-scalar false-positive handling -- record that definition either as a Terminology row in Section 3 or as normative prose within req-lk-023. This replaces the interim illustrative parenthetical from F1 with the binding grammar.
-
[F5 / standalone] req-lk-023 clause (a) -- Evaluate whether 'through the same authority that loads the lockfile' should be relaxed to a temporal-behavioral constraint such as 'before or during the load that would otherwise attempt to parse the lockfile'. The current phrasing reflects an intentional single-authority design choice by the specification owner; any relaxation should be weighed against cross-implementation feedback and the observable-versus-architectural constraint distinction. This is a proposed refinement, not a defect.
Findings not carried forward
- pkg-nit-r1-2 -- False premise. The Appendix D 0.1.42 row already uses the normative abstraction ('directs the user to resolve the conflict or restore a known-good lockfile, never to another operation that reads the same unreadable file'). The alleged git-command phrasing ('name the git command that keeps one side of the merge') does not appear in the specification row; it originates from companion documentation (lockfile-spec.md), which is non-normative. No change to the already-correct Appendix D row is warranted.
Linter notes (1 advisory check failed; 1 mixed-scope note)
- [10] The Unreleased changelog entry does not mention the spec file path. The checklist treats that as an advisory SHOULD, not a correctness failure.
- [11] Twelve Python files also changed; this is why the separate general panel applies. Its test reviewer was unavailable. No contribution code/tests or Python lint chain were run locally.
- [1-7, 9] Mechanical checks passed: ASCII, banned-affiliation scan, five Appendix-A-referenced schema checks, 17 fixture parses, 124 unique requirement anchors, matching counts, internal links and fixture citations.
- [8] Skipped as not applicable: zero Mermaid blocks.
Note: the synthesizer recommends folding the suggestions; the changelog note is worth addressing in that same pass if the maintainer accepts the amendment.
Linter handoff: F1's quoted insertion phrase is in the req-lk-023 preamble, not clause (a); use that actual location. A heading rename in F2 also changes its generated slug, so check all affected links. After any accepted edits, recheck 124 requirement anchors/count sites, section links, ASCII and affiliation-language restrictions. These results describe the current head only, not unperformed folds or a full CI certification.
Full per-panel findings
Swagger / OpenAPI Editor -- shocked_meter 8/10, confidence high
Summary: Clean single-requirement editorial-patch amendment. The normative text is internally consistent, counts match across all three locations, conformance class is correct, and the defensive non-authorization clause for automatic recovery is well-crafted. One substantive gap: the MUST-level obligation references an undefined term ('version-control merge conflict markers') that should be pinned to an observable byte pattern for interface-contract interoperability. Two editorial nits on keyword formality and section-heading staleness. No blocking findings.
New recommended findings (1)
- [sw-rec-r1-1] req-lk-023 / Section 3 -- req-lk-023 binds a MUST to 'version-control merge conflict markers' but the term is undefined in Section 3 (Terminology) or inline. Two conformant implementations could disagree on which byte patterns constitute a marker (e.g. diff3-style angle-bracket lines vs. Perforce-style markers). The non-normative companion lockfile-spec.md pins specific Git patterns (seven angle-brackets / pipes at line start), but the normative spec does not. For an interface contract this is an interoperability gap: clause (a) says MUST identify 'the markers' but does not define what to identify.
Recommended fix: Add either (i) a Terminology row in Section 3 defining 'merge conflict marker' as any line beginning with seven consecutive angle-bracket or pipe characters followed by a space or end-of-line (the diff3 convention shared by Git, Mercurial, and SVN), or (ii) a parenthetical in the req-lk-023 preamble: '...carrying unresolved version-control merge conflict markers (lines beginning with seven left-angle-brackets, seven right-angle-brackets, or seven pipe characters, each followed by a space or end-of-line)'. Either approach pins the MUST to an observable pattern without over-fitting to a single VCS.
New nit findings (2)
- [sw-nit-r1-1] req-lk-023 clause (b) -- The tail of clause (b) reads 'naming one as a follow-up step, sequenced after the resolving action, is permitted.' The word 'permitted' functions as a normative grant but is not a BCP 14 keyword. Section 2 says lowercase variants carry no normative weight, so the sentence is technically non-normative -- yet it clearly intends to grant permission. Strict RFC 2119 / 8174 discipline would use MAY.
Recommended fix: Rewrite the sentence: '...naming one as a follow-up step, sequenced after the resolving action, MAY be included in the diagnostic.' - [sw-nit-r1-2] Section 5.4 heading -- The heading reads 'Lockfile versions (1, 2) and bumping rules' but now also houses req-lk-023 (conflict-marker recognition), which is not a versioning or bumping concern. The heading text is stale relative to the broadened section scope.
Recommended fix: Broaden the heading to reflect all load-time gatekeeping, e.g. 'Lockfile versions, bumping rules, and load-time validation', or split req-lk-023 into its own subsection (5.4.1).
Preserved strengths confirmed
- Mechanical checks independently found 124 unique normative anchors and matching Section 1.3, Appendix C and latest Appendix D totals.
- Consumer MUST enumeration and explicit exclusion of automatic recovery are preserved.
OCI Distribution Editor -- shocked_meter 8/10, confidence high
Summary: The diagnosis-only amendment preserves the file and avoids raw parser output. The marker pattern set needs precision for consistent diagnostics; no security bypass was demonstrated.
New recommended findings (1)
- [oci-rec-r1-1] req-lk-023 -- The MUST refers to unresolved version-control merge conflict markers without defining recognizable byte patterns. The implementation excludes a bare ======= separator and scans opener, closer and diff3 forms, but this choice is not explicit in the normative text. Implementations can therefore disagree about which inputs receive the named conflict diagnosis. No security bypass was demonstrated.
Recommended fix: Add a non-normative note immediately after the closing paragraph of req-lk-023, e.g.: 'Note -- For the purposes of this requirement, the canonical conflict marker set is the sequences <<<<<<< , >>>>>>> , and ||||||| appearing at the start of a line, each followed by a space or end-of-line. A bare ======= separator alone is not sufficient evidence of a conflict because a real version-control conflict always includes at least one labelled marker.' This keeps the MUST portable across VCS tooling while giving implementers a shared reference set for conformance testing.
New nit findings (1)
- [oci-nit-r1-1] sec.5.4 -- req-lk-023 is placed in Section 5.4 whose title is 'Lockfile versions (1, 2) and bumping rules'. Conflict-marker detection is a load-time validation concern, not a versioning or bumping concern. The Appendix C index correctly lists the section as 5.4, so a reader scanning by section title would miss the requirement.
Recommended fix: No immediate action required; note for the next editorial pass that Section 5.4 could be retitled to 'Lockfile versions, load-time validation, and bumping rules' or that req-lk-023 could move to a dedicated subsection if the pre-parse validation surface grows.
Preserved strengths confirmed
- Clause (c) preserves the conflicted lockfile rather than introducing automatic discard or re-resolution.
- Clause (a) names path and cause without raw parser output.
- Synthesis correction: the new requirement does not impose a different exit policy, and Appendix D 0.1.42 does not state a Section 9.2 compatibility classification. Unsupported raw-return claims to those effects were not carried forward.
Package-Manager Editor -- shocked_meter 7/10, confidence high
Summary: Diagnostic-only scope and consumer classification are coherent. Marker grammar needs precision; the heading is stale. One Appendix D nit was discarded during synthesis because its premise was false.
New recommended findings (1)
- [pkg-rec-r1-1] req-lk-023 -- The requirement refers to unresolved version-control merge conflict markers without a minimum recognizable grammar. One implementation might diagnose a single labeled marker while another requires a complete opener/separator/closer group. The conformance oracle uses concrete Git-style input without defining the portable minimum in normative text. Existing best-effort readers and exit semantics are outside this ambiguity; this finding does not imply every command fails closed.
Recommended fix: Add a parenthetical example list after the first mention in req-lk-023: 'unresolved version-control merge conflict markers (for example, a line beginning with seven less-than, greater-than, or pipe characters followed by a space or end-of-line)'. This aligns the normative text with the conformance oracle without excluding implementations that recognise additional VCS marker formats.
New nit findings (2)
- [pkg-nit-r1-1] sec.5.4 -- req-lk-023 is placed inside Section 5.4 whose heading is 'Lockfile versions (1, 2) and bumping rules.' The requirement governs conflict-marker diagnosis, not version semantics. While editorially adjacent to req-lk-004 (unrecognised lockfile_version), the heading misleads implementers scanning by section title for the marker-diagnosis contract.
Recommended fix: Defer to a future editorial pass: rename Section 5.4 to 'Lockfile versions, bumping rules, and load-time diagnostics' or extract req-lk-023 into a new subsection 5.4.1. - [pkg-nit-r1-2] Appendix D row 0.1.42 -- Not carried forward: this item misattributed companion Git-command wording to Appendix D. The actual row already says resolve the conflict or restore a known-good lockfile; no change to that row is warranted.
Preserved strengths confirmed
- Clause (c) MUST-NOT ('MUST NOT remove, rewrite, or re-resolve the lockfile in response to the markers') is a proper defensive reservation that requires a future amendment to lift, preventing silent recovery drift without an explicit spec change.
- Consumer-only conformance class assignment is correct; the requirement imposes no Producer, Registry, or Governance obligations, and the Appendix C, Section 5.7, and Section 11.3.2 enumerations are all consistent.
- The closing scope limiter ('this requirement neither defines nor authorises automatic recovery from a conflicted lockfile') explicitly defers recovery without foreclosing it, matching the reserved-slot pattern used for workspaces, conflict_resolution:nest, and other v0.2 deferrals.
- The diagnostic-advice clause (b) makes no false pin-preservation claim: 'resolve the conflict in that file or restore a known-good lockfile' honestly encompasses lossy single-side restoration (which discards the opposite side's recorded pins) alongside lossless manual merge, without promising that both parents' transitive resolutions survive.
TAG Architect -- shocked_meter 8/10, confidence high
Summary: req-lk-023 is well-structured, properly integrated across all conformance surfaces, and defensively scoped. Two recommended findings address cross-implementation clarity: the marker grammar is normatively referenced but never enumerated, and clause (a) embeds an untestable architectural constraint alongside testable behavioural requirements. Neither is blocking; both are cleanly deferrable to a v0.1.x followup.
New recommended findings (2)
- [tag-rec-r1-1] req-lk-023 clause (a) -- The normative text requires a consumer to recognise 'unresolved version-control merge conflict markers' but never enumerates or references a grammar for those markers. Git two-way conflicts use lines beginning with '<<<<<<< ' and '>>>>>>> '; diff3 mode adds '||||||| '; other VCS systems use different sequences. A third-party implementer reading only the spec cannot produce an interoperable marker scanner, and two conformant implementations may disagree on what constitutes a marker. The conformance test fixture exercises only one git-style pattern, reinforcing the gap between the normative prose and testable surface.
Recommended fix: Add an informative parenthetical or non-normative note after 'version-control merge conflict markers' in clause (a), e.g.: '(in practice, lines beginning with the seven-character sequences "<<<<<<< ", ">>>>>>> ", and optionally "||||||| ", as emitted by git and compatible tooling)'. This keeps the normative MUST behavioural while giving implementers an enumerable minimum marker set to target. - [tag-rec-r1-2] req-lk-023 clause (a) -- The phrase 'through the same authority that loads the lockfile' is an architectural constraint on implementation structure: it mandates that detection and loading share a code path. No conformance test can observe whether an implementation uses one code path or two -- only whether the observable output (correct detection, path in diagnostic, no raw parser spill) is correct. Normative statements should constrain observable behaviour rather than internal architecture, to avoid over-constraining implementations that use a pre-scan, a separate YAML front-end, or a streaming parser.
Recommended fix: Replace 'through the same authority that loads the lockfile' with 'before or during the load that would otherwise attempt to parse the lockfile', or 'at lockfile-load time'. This preserves the temporal invariant (detection happens during the load phase, not later) without constraining implementation topology.
New nit findings (1)
- [tag-nit-r1-1] req-lk-023 / Section 5.4 -- req-lk-023 is placed in Section 5.4 ('Lockfile versions (1, 2) and bumping rules') but governs pre-parse conflict-marker detection, not version semantics. The Appendix C table cites section 5.4 accordingly. A reader scanning by section topic would not look under 'versions and bumping rules' for conflict-marker handling. The placement after req-lk-004 (pre-parse version rejection) is defensible as a 'load-time rejection' cluster, but the section title does not reflect this broader scope.
Recommended fix: Consider a one-line parenthetical in the section heading or a brief editorial note acknowledging that req-lk-023 governs a load-time pre-parse condition co-located here with req-lk-004 for that reason. Alternatively, if a future revision introduces a 'Section 5.4.1 Load-time rejection' subsection, both requirements could migrate there.
Preserved strengths confirmed
- The new requirement, manifest entry, Appendix C row and conformance bindings agree on consumer classification.
- The closing scope paragraph excludes unrelated corruption and automatic recovery.
- Three conformance tests are bound to req-lk-023; this is static binding evidence, not an executed-test result.
- Synthesis correction: the new requirement does not impose a different exit policy, and Appendix D 0.1.42 does not state a Section 9.2 compatibility classification. Unsupported raw-return claims to those effects were not carried forward.
This panel is advisory. It does not block merge. Re-apply the spec-review label after addressing feedback to re-run.
Generated by apm-spec-guardian. This comment is AI-generated and may contain errors.
…rror
Every command that reads apm.lock.yaml exits 1 with a raw PyYAML scanner
error when the file still carries conflict fences, and the --frozen tip
points at 'apm outdated' and 'apm update', which fail on the same file.
The only recovery is deleting the lockfile by hand, which re-resolves
every pin.
LockFile.read, already the single load owner, detects the markers before
parsing and raises LockfileConflictError, a LockfileFormatError. The
diagnostic names the lockfile and prints the recovery as commands the
user can run:
git checkout apm.lock.yaml --ours # or --theirs
apm install
Keeping one side preserves that branch's recorded pins, and the install
reconciles only what the merge added. The drift tip is suppressed when a
FrozenInstallError carries no drift reasons, so a missing or unreadable
lockfile no longer inherits advice meant for stale pins.
The conflicted lockfile is preserved byte-for-byte on every path:
install, lock, --frozen, --only, --mcp, --dry-run, update, outdated, and
lock export. A lockfile invalid for any other reason keeps its existing
handling, and a bare '=======' separator is not treated as a marker.
Tests assert the printed recipe by running it against a real merge
conflict rather than by matching its wording, so the message can be
rephrased without breaking them.
Automatic recovery is deliberately out of scope and remains deferred;
see the scope decision on microsoft#2979.
Refs microsoft#2979
Head branch was pushed to by a user without write access
c19ca66 to
a9c6e13
Compare
|
Narrowed to the diagnostic slice and rebased onto The message names the repair as commands rather than prose: The git command resolves the file before any Tests run the printed commands against a real merge conflict rather than matching their wording, so the message can be rephrased without breaking them. On point 4 -- the diagnostic-only diff still trips Mode B (61 lines, threshold 20), and no existing requirement covers lockfile-load diagnostics; |
Maintainer agreement: diagnostic-only req-lk-023Recording Daniel Meppiel (@danielmeppiel)'s explicit human decision to approve and record req-lk-023, following presentation of the current proposed requirement and its limits. The agreement applies to the text in
The marker examples remain illustrative, not a newly fixed normative grammar. Other unreadable-lockfile conditions remain outside this requirement. This is agreement to the proposed diagnostic-only specification amendment, not an agent review verdict. It does not approve automatic recovery, workflow execution, PR approval or merge. The bounded scope on #2979 remains unchanged, and that issue remains open. Any substantive change to the approved requirement needs renewed human agreement. Generated by autopilot-pr-merge-worker. This comment is AI-generated and may contain errors. |
APM Review Panel:
|
| Persona | B | R | N | Takeaway |
|---|---|---|---|---|
| python-architect | 0 | 1 | 1 | Guard gap: lockfile-read boundary check covers filename resolution but misses LockFile.read load authority and lock_export consumer. One recommended follow-up, one nit. |
| test-coverage-expert | 0 | 3 | 0 | Preservation assertions use text-mode read; conformance test omits command-outcome check; no global-scope integration test. |
| devx-ux-expert | 0 | 2 | 1 | Docs couple the diagnostic description to a specific git recipe instead of the spec's generic resolve-or-restore language; troubleshooting page drops the mid-merge caveat the CLI includes. |
| supply-chain-security-expert | 0 | 1 | 0 | Conflict detection, redaction, and preservation are sound. One advice-to-shell quoting gap found. |
| doc-writer | 0 | 3 | 0 | Recovery guidance overstates applicability and pin preservation; export docs miss the legacy read-only fallback. Static review only; normative agreement remains pending. |
| performance-expert | 0 | 0 | 0 | No algorithmic or I/O regression. Conflict-marker regex is a compiled single-pass O(n) scan preceding the O(n) YAML parse on every lockfile read; no new network, cache, or materialization cost. |
| cli-logging-expert | 0 | 0 | 0 | Conflict diagnostics use correct severity levels, message structure, and CommandLogger APIs; no output UX regressions found. |
B = blocking-severity findings, R = recommended, N = nits.
Counts are signal strength, not gates. The maintainer ships.
Top 4 follow-ups
- [devx-ux-expert] Replace the git-specific recovery recipe with generic resolve-or-restore guidance; retry the original invocation preserving scope and flags; align all affected docs, help text, agent-guide, and changelog. -- Three panelists converge (devx-ux, doc-writer, supply-chain): the current recipe assumes an active git merge, does not cover post-merge or non-git scenarios, and couples six doc surfaces to a single recovery path. Generic guidance aligns with req-lk-023(b) and subsumes the path-quoting and _command_path concerns.
- [python-architect] Enroll lock_export in _LOCKFILE_CONSUMERS; extend the boundary check to verify LockFile.read usage; add a mutation case that kills LockFile.read delegation. -- The fix correctly routes lock_export through LockFile.read, but the static guard does not yet enforce this, leaving the bypass unprotected against silent regression.
- [test-coverage-expert] Strengthen preservation assertion to read_bytes in at least one parametrized case; add per-command exit-code gate in the conformance test (dry-run exits 0); add global/ancestor scope integration test. -- read_text normalizes line endings on all platforms, masking potential re-encoding. The conformance test preserves bytes but does not gate outcomes per-command. Global-scope path rendering is unit-tested but not exercised end-to-end.
- [doc-writer] Document legacy-lockfile input for read-only export in reference, CLI help, and agent-guide. -- resolve_lockfile_path_for_read already supports apm.lock fallback, but export docs and help still say apm.lock.yaml only. Directly affected by this PR routing of lock_export through the canonical load path.
Architecture
classDiagram
direction LR
class LockFile {
<<CanonicalOwner>>
+read(path) LockFile or None
+from_yaml(yaml_str) LockFile
+load_or_create(path) LockFile
+write(path)
}
class LockfileFormatError {
<<DomainException>>
}
class LockfileConflictError {
<<DomainException>>
+path Path
}
class UnsupportedLockfileVersionError {
<<DomainException>>
}
class InstallService {
<<Facade>>
+enforce_frozen(request)
}
class FrozenInstallError {
<<DomainException>>
+reasons list
}
class resolve_lockfile_path_for_read {
<<Pure>>
}
class has_conflict_markers {
<<Pure>>
}
class lock_export {
<<IOBoundary>>
}
class frozen_install_tip {
<<Pure>>
}
LockfileFormatError <|-- LockfileConflictError
LockfileFormatError <|-- UnsupportedLockfileVersionError
LockFile ..> LockfileConflictError : raises
LockFile ..> has_conflict_markers : gates parse
LockFile ..> LockfileFormatError : raises
InstallService ..> LockFile : reads via LockFile.read
InstallService ..> LockfileConflictError : catches
InstallService ..> FrozenInstallError : raises
lock_export ..> resolve_lockfile_path_for_read : resolves path
lock_export ..> LockFile : reads via LockFile.read
frozen_install_tip ..> FrozenInstallError : reads reasons
note for LockFile "Canonical load authority: read() gates\nall callers through has_conflict_markers\nbefore from_yaml"
note for resolve_lockfile_path_for_read "Canonical filename resolution:\nmigration guard for mutating callers"
class LockfileConflictError:::touched
class has_conflict_markers:::touched
class lock_export:::touched
class LockFile:::touched
class InstallService:::touched
class frozen_install_tip:::touched
classDef touched fill:#fff3b0,stroke:#d47600
flowchart TD
A["CLI: apm lock export\nsrc/apm_cli/commands/lock.py:289"] --> B["resolve_lockfile_path_for_read root read_only=True\nsrc/apm_cli/deps/lockfile.py:1272"]
A2["CLI: apm install --frozen\nsrc/apm_cli/install/service.py:257"] --> C["enforce_frozen request\nsrc/apm_cli/install/service.py:257"]
B --> D["I/O LockFile.read lockfile_path\nsrc/apm_cli/deps/lockfile.py:1076"]
C --> D
D --> E{"path.exists?"}
E -- No --> F["return None"]
E -- Yes --> G["I/O path.read_text encoding utf-8\nsrc/apm_cli/deps/lockfile.py:1081"]
G --> H{"has_conflict_markers text?\nsrc/apm_cli/deps/lockfile.py:1082"}
H -- Yes --> I["raise LockfileConflictError path\nsrc/apm_cli/deps/lockfile.py:1083"]
H -- No --> J["LockFile.from_yaml text\nsrc/apm_cli/deps/lockfile.py:1084"]
J --> K{"YAML parse OK?"}
K -- Yes --> L["return LockFile instance"]
K -- No --> M["raise LockfileFormatError"]
I --> N{"caller context"}
N -- lock_export --> O["propagates to CLI error handler\nexit 1"]
N -- enforce_frozen --> P["catch LockfileConflictError\nsrc/apm_cli/install/service.py:287"]
P --> Q["raise FrozenInstallError\nwith conflict guidance"]
Q --> R["frozen_install_tip error\nsrc/apm_cli/install/errors.py:96"]
R --> S{"error.reasons empty?"}
S -- Yes --> T["return empty string\nno misleading advice"]
S -- No --> U["return tailored tip"]
Recommendation
The detection, preservation, and error-hierarchy design are sound. The driver is folding scoped follow-ups (generic guidance, static guard enrollment, test strengthening, export docs) into the PR before readiness. Human req-lk-023 agreement is recorded at comment 5877881070; renewed human PR review and exact-final-head CI remain pending. Local baseline at a9c6e13: 74 passed, 1 skipped, 1.99s -- does not yet cover newly promised tests.
Full per-persona findings
python-architect
-
[recommended] Static guard contracts-tooling-lockfile-read covers filename resolution but not LockFile.read load authority or lock_export consumer at
scripts/architecture_linter/checks/contracts_test_taxonomy.py:94
The architecture boundary check check_lockfile_read_resolution (contracts_test_taxonomy.py:169) verifies that four enumerated consumers in _LOCKFILE_CONSUMERS (line 94) delegate filename resolution through resolve_lockfile_path_for_read. However, it does NOT verify that consumers call LockFile.read() -- the canonical load authority that now gates all lockfile loads through has_conflict_markers before from_yaml parsing -- rather than calling LockFile.from_yaml() directly. The PR correctly fixes lock_export (lock.py:306-307) to route through both resolve_lockfile_path_for_read and LockFile.read, closing the export bypass that previously called get_lockfile_path + LockFile.from_yaml. But src/apm_cli/commands/lock.py is absent from _LOCKFILE_CONSUMERS, so the fix has no static regression protection. The mutation case (test_architecture_owner_rule_mutations.py:151) proves the guard has teeth for the read_only migration gate, but no mutation case covers a LockFile.read bypass or the lock_export consumer. Per the single-owner rule, every fix without a dual guardrail (behavioral + static) will silently regress. Follow-up should: (1) add lock.py to _LOCKFILE_CONSUMERS, (2) extend the guard to verify LockFile.read usage where from_yaml bypass is structurally reachable, and (3) add a mutation case that kills the LockFile.read delegation in one consumer. Design patterns -- Used in this PR: Base class + subclass (LockfileConflictError extends LockfileFormatError, enabling callers to catch at the granularity they need). Template Method (implicit): LockFile.read is the single entry that gates all loads through conflict detection before from_yaml, enforcing the canonical-owner rule at runtime. Pragmatic suggestion: none -- the current hierarchy is the simplest correct design at this scope.
Suggested: Add 'src/apm_cli/commands/lock.py' to _LOCKFILE_CONSUMERS; extend check_lockfile_read_resolution to verify LockFile.read usage (not just resolve_lockfile_path_for_read); add a MutationCase that kills the LockFile.read delegation.
Evidence: unknown / static. -
[nit] _command_path couples CWD-relative rendering into LockfileConflictError domain exception at
src/apm_cli/deps/lockfile.py:81
_command_path (lockfile.py:81-93) computes os.path.relpath at exception-construction time and bakes the result into the error message string. This embeds a presentation concern (rendering git checkout commands relative to the invoking directory) into a domain exception. Currently tolerable: one raiser (LockFile.read:1083), two catchers (enforce_frozen in service.py:287 wraps the message; lock_export in lock.py:307 lets it propagate). If additional callers need to customize the rendered advice (e.g. a future IDE integration or JSON-output mode), the fixed string prevents re-rendering. At current scope no extraction is warranted; note for future refactoring if a third distinct rendering context appears.
test-coverage-expert
-
[recommended] Preservation assertions use read_text (universal-newline mode), cannot detect CRLF re-encoding. at
tests/integration/test_install_conflicted_lockfile_e2e.py:108
Both the integration e2e test (line 108) and the spec conformance test (line 852) assert lockfile preservation via Path.read_text(encoding='utf-8'), which applies Python universal-newline translation (\r\n -> \n on all OSes, not just Windows). The PR body and req-lk-023(c) promise 'byte-for-byte' preservation, but this assertion would pass even if a regression rewrote the file with different line endings. A read_bytes() comparison would be strictly stronger. Probed: grep'd all three new test files for read_bytes and newline= -- zero matches for lockfile preservation; the conformance file uses read_bytes only for unrelated trust-archive paths (lines 241, 288, 652, 672).
Suggested: Replace the read_text preservation assertion with read_bytes() and compare against _CONFLICTED_LOCKFILE.encode('utf-8') in at least one parametrized case, e.g.: assert (conflicted_project / 'apm.lock.yaml').read_bytes() == _CONFLICTED_LOCKFILE.encode('utf-8').
Evidence: unknown / integration-with-fixtures. -
[recommended] Conformance test for req-lk-023(c) does not assert command outcome (exit code or exception type). at
tests/spec_conformance/test_lockfile_reqs.py:849
test_lockfile_is_left_unmodified_across_reading_operations invokes four command sets with catch_exceptions=True (line 849) but never asserts result.exit_code or isinstance(result.exception, LockfileConflictError). The preservation assertion alone would pass under a regression that changes the diagnostic shape (e.g. raw YAMLError instead of named LockfileConflictError) or even if the command silently succeeds and happens not to write the file. The companion e2e test does assert exit_code == 1, but the conformance test -- the one mapped to the ratified spec clause -- does not carry a parallel outcome gate. Probed: grep'd test_lockfile_reqs.py for exit_code and exception near the req-lk-023(c) test -- zero matches inside that test function.
Suggested: After the invoke, add: assert result.exit_code != 0, f'{args} MUST fail when the lockfile contains conflict markers'.
Evidence: unknown / integration-with-fixtures. -
[recommended] No integration test exercises a conflicted lockfile in global or ancestor scope. at
tests/integration/test_install_conflicted_lockfile_e2e.py
All new tests create a project-scope apm.lock.yaml in tmp_path. The production code _command_path() renders an absolute path when the lockfile is outside the working directory (tested at unit tier in test_recovery_command_targets_a_lockfile_outside_the_working_directory), but no Click CliRunner test exercises apm install -g or an ancestor lockfile via the full command flow. Probe: grep'd tests/ for global.*conflict, conflict.*global, --global.*conflict, -g.*conflict -- only unrelated MCP conflict detection and Docker installer files matched (tests/unit/test_conflict_detection.py, tests/unit/test_docker_args_and_installer.py).
Suggested: Add a parametrized case to the e2e test that places the conflicted lockfile at a user-scope path and invokes install -g, asserting exit_code == 1 and the absolute path in the diagnostic.
Evidence: missing / integration-with-fixtures.
devx-ux-expert
-
[recommended] Reference docs describe the diagnostic as naming a specific git command, coupling prose to the current recipe. at
docs/src/content/docs/reference/lockfile-spec.md:412
req-lk-023 clause (b) deliberately says 'resolve the conflict in that file or restore a known-good lockfile' -- generic, not git-specific. lockfile-spec.md line 412 and install.md line 165 both say 'name the git command that keeps one side of the merge', tying the user-facing reference doc to the current git-checkout recipe. If the recipe is revised to generic resolve/restore guidance, these doc paragraphs require parallel edits. Reference docs are what a confused user reads first; they should reflect the spec's broader framing rather than pin a single recovery path.
Suggested: Replace 'name the git command that keeps one side of the merge so you can reinstall from it' with language closer to the spec: 'direct you to resolve the conflict or restore a known-good lockfile before reinstalling'. Apply the same change in install.md's frozen-mode bullet.
Evidence: unknown / static. -
[recommended] Troubleshooting page drops the mid-merge caveat from the recovery recipe. at
docs/src/content/docs/troubleshooting/install-failures.md:185
The CLI diagnostic at lockfile.py:104 says '# or --theirs, mid-merge only' but install-failures.md line 185 shows only '# or --theirs'. git checkout --ours/--theirs requires an active merge; outside an active merge git returns 'error: --ours/--theirs is incompatible with switching branches'. A user who committed the conflicted file and encounters this page later gets a confusing git error with no fallback guidance. The CLI is more honest about the precondition; the doc should match or offer an alternative for the post-merge case.
Suggested: Add '# mid-merge only' to the comment, matching the CLI output, or add a sentence noting that outside an active merge the user can restore from version control instead (e.g., git checkout main -- apm.lock.yaml).
Evidence: unknown / static. -
[nit] Recovery recipe does not quote the lockfile path in the printed git command. at
src/apm_cli/deps/lockfile.py:104
The _command_path helper returns an unquoted path string. For the common project-relative apm.lock.yaml this is safe, but a user-scope lockfile under a home directory with spaces (e.g., C:\Users\John Doe...) produces a git command that splits on whitespace. The printed recipe is the one concrete action the error offers; a broken command on the first try undermines the recovery UX.
Suggested: Quote the target path in the printed recipe, e.g., git checkout "{target}" --ours, or use shlex.quote(target) for the display string.
Evidence: unknown / static.
supply-chain-security-expert
- [recommended] Shell recipe interpolates the lockfile path unquoted into a git checkout command. at
src/apm_cli/deps/lockfile.py:104
LockfileConflictError.init embeds _command_path(path) directly into the git checkout recipe without shell quoting. If the resolved path contains spaces or shell metacharacters such as dollar signs, a user who copy-pastes the advice gets a broken or misinterpreted command. The test helper diagnostic_recipe.py already imports shlex and splits safely, but the production side that composes the recipe does not quote. shlex.quote(target) would close this gap for all path spellings.
Suggested: Add 'import shlex' to module imports; change line 104 to: f" git checkout {shlex.quote(target)} --ours # or --theirs, mid-merge only\n"
Evidence: manual / static.
doc-writer
-
[recommended] Replace the unconditional recovery recipe with context-safe manual guidance. at
docs/src/content/docs/troubleshooting/install-failures.md:185
The troubleshooting recipe and packages/apm-guide/.apm/skills/apm-usage/troubleshooting.md:14 omit the 'mid-merge only' qualification now present in src/apm_cli/deps/lockfile.py:104. Conflict markers can survive in a committed file with no unmerged index stages, so selecting --ours or --theirs is not a general repair. The basename also assumes the lockfile is in the current directory. The subsequent plain 'apm install' loses the original command and scope: src/apm_cli/commands/install.py:1357 selects project scope unless --global is supplied, so a user-scope failure can lead to installing an unrelated project. The frozen PR body further contradicts this head by saying the diagnostic 'deliberately names no command'. These are static observations; no recipe or contribution tests were executed.
Suggested: Make manual resolution of the named file or restoration of a known-good lockfile the general instruction, followed by retrying the original invocation with its scope and flags. If retaining a Git example, explicitly restrict it to an active merge in the owning repository. Align the canonical diagnostic, troubleshooting guide, agent guide, reference summaries, changelog, and PR body without adding a recovery command or policy.
Evidence: unknown / static. -
[recommended] Do not promise that selecting one side preserves all pins through reinstall. at
docs/src/content/docs/troubleshooting/install-failures.md:189
The claim that reinstall 'resolves only what the merge added' is stronger than the existing replay contract: docs/src/content/docs/reference/cli/install.md:162 limits locked-commit reuse to unchanged Git dependencies. A merge can change an existing dependency's ref, not just add dependencies. Selecting one whole lockfile side also drops records unique to the other side; the agent-guide troubleshooting row's assurance that pins and deployment records 'are not lost' obscures that distinction. The new recipe test invokes run_recipe with only='git' and then checks that LockFile.read succeeds; it does not run the follow-up install or establish post-repair pin preservation. No tests were executed in this review.
Suggested: State that APM leaves the conflicted file unchanged until the user repairs it. Explain that selecting a side retains only that side's records and that changed or missing dependencies may resolve again; require reviewing the repaired lockfile against the merged manifest before committing. Replace the unconditional agent-guide assurance with the same bounded statement and link to the existing replay contract.
Evidence: unknown / static. -
[recommended] Document the newly supported legacy lockfile input for read-only export. at
docs/src/content/docs/reference/cli/lock.md:79
The export reference still says it reads 'apm.lock.yaml only'. At this head, src/apm_cli/commands/lock.py:306 calls resolve_lockfile_path_for_read(project_root, read_only=True), whose implementation in src/apm_cli/deps/lockfile.py:1272-1282 selects apm.lock when apm.lock.yaml is absent without renaming it. The same stale input restriction remains in both export rows of packages/apm-guide/.apm/skills/apm-usage/commands.md:440 and :442 and in the export help at src/apm_cli/commands/lock.py:252. Readers with a legacy lockfile are not told that they can export directly without first generating or migrating a lockfile. This is verified from source only; no export invocation or tests were executed.
Suggested: State once in the export reference that apm.lock.yaml takes precedence and legacy apm.lock is read in place when the current filename is absent. Align the CLI help and agent-guide export descriptions with that behavior, preserving the no-resolution, no-rehash, and no-network guarantees.
Evidence: unknown / static.
performance-expert
No findings.
cli-logging-expert
No findings.
This panel is advisory. It does not block merge. Re-apply the panel-review label after addressing feedback to re-run.
Generated by autopilot-pr-review-worker. This comment is AI-generated and may contain errors.
Address the current general panel's manual-repair, canonical export routing, byte-preservation and documentation follow-ups. Replace the Git recipe with resolve-or-restore guidance followed by the original invocation; protect LF/CRLF, scope and legacy reads with behavioral and architecture regressions. Apply the specification panel's editorial folds without changing the human-agreed req-lk-023 obligations. References microsoft#2979 without closing its deferred automatic-recovery work. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
fix(lockfile): diagnose merge conflicts without automatic recovery
TL;DR
Report unresolved lockfile conflict markers through the canonical loader, with
the actual path, a redacted cause and manual resolve-or-restore guidance.
Retry the original command after repair; do not substitute an unqualified
install or a Git command that works only during a live merge.
The diagnostic leaves conflicted bytes untouched and preserves existing
frozen, preview, best-effort and unrelated-corruption behavior.
Important
This PR delivers only the approved diagnostic slice.
Refs #2979; it does not close that issue or implement automatic recovery.
Human agreement to the exact req-lk-023 text
is recorded separately from workflow execution and PR review.
Renewed human PR review remains required.
Problem (WHY)
at the canonical load boundary; some callers offered commands that needed to
read the same unreadable file.
caller's scope/options with plain
apm install.Contract-specific guidance follows
"Add what the agent lacks, omit what it knows".
The regression evidence follows the validation loop:
"do the work, run a validator (a script, a reference checklist, or a self-check), fix any issues, and repeat until validation passes.".
Approach (WHAT)
LockFile.read, before YAMLparsing and within its existing error-normalization boundary.
executed tests and deliberate failing mutations.
Implementation (HOW)
src/apm_cli/deps/lockfile.pyLockfileConflictErrorand marker recognition; remove the intermediate shell-path/recipe helper. Keep read/parse normalization together.src/apm_cli/commands/lock.pysrc/apm_cli/install/service.pysrc/apm_cli/install/errors.pysrc/apm_cli/commands/install.pysrc/apm_cli/install/mcp/command.pysrc/apm_cli/install/presentation/dry_run.pyscripts/architecture_linter/checks/contracts_test_taxonomy.pytests/integration/test_architecture_pack_lockfile_read.pytests/unit/deps/test_lockfile_conflict_markers.pytests/unit/install/test_frozen.pytests/integration/test_install_conflicted_lockfile_e2e.pycomponent, not subprocess E2E.tests/spec_conformance/test_lockfile_reqs.pydocs/src/content/docs/specs/openapm-v0.1.mddocs/public/specs/manifests/openapm-v0.1.requirements.ymlCONFORMANCE.md,CONFORMANCE.jsondocs/src/content/docs/reference/cli/install.mddocs/src/content/docs/reference/cli/lock.mddocs/src/content/docs/reference/lockfile-spec.mddocs/src/content/docs/troubleshooting/install-failures.mdpackages/apm-guide/.apm/skills/apm-usage/commands.mdpackages/apm-guide/.apm/skills/apm-usage/troubleshooting.mdCHANGELOG.mdThe intermediate
tests/utils/diagnostic_recipe.pyis removed; tests no longerextract or execute shell advice.
Original contributor commits and attribution are retained.
Diagrams
Legend: the canonical loader owns detection; callers keep their existing policy,
and dashed boxes identify the diagnostic/routing additions.
flowchart LR subgraph Input["Lockfile consumers"] C["install / frozen / preview / update / outdated"] E["lock_export"] P["resolve_lockfile_path_for_read"] E --> P end subgraph Owner["deps/lockfile.py"] R["LockFile.read"] M{"has_conflict_markers"} Y["from_yaml"] X["LockfileConflictError: path, cause, manual repair"] R --> M M -->|"no"| Y M -->|"yes"| X end C --> R P --> R X --> H["Caller keeps existing exit or best-effort policy; no lockfile rewrite"] classDef new stroke-dasharray: 5 5; class M,X,P new;Trade-offs
records; automatic discard/recovery remains out of scope on [Feature] Automatic recovery of merge-conflicted lockfiles #2979.
recognizes seven
<,>or|characters at line start followed by space/endof line. Bare
=======and inline value text remain negative cases.best-effort inventory discovery still returns an empty result. This PR does
not add transaction rollback for prior MCP manifest writes or redesign legacy
migration in mutating commands.
guard; keep the Section 5.4 slug and all agreed normative obligations unchanged.
Benefits
user's original global/frozen/preview invocation.
precedence and a static guard against bypassing the loader.
Validation
At
05dafde1c3799d34eeebe04b10109c50b220550b:uv run --frozen --extra dev pytest -q tests/unit/deps/test_lockfile_conflict_markers.py tests/unit/install/test_frozen.py tests/integration/test_install_conflicted_lockfile_e2e.py tests/spec_conformance/test_lockfile_reqs.py tests/integration/test_architecture_pack_lockfile_read.pyAdditional executed evidence
The same code before commit, with the owner-mutation matrix and
tests/quality:Canonical Ruff check/format:
Pylint R0801, auth boundary, architecture boundary, YAML I/O, 2100-line and
portable-relative-path guards passed. The three grep/awk guards were also
checked using equivalent Python patterns on macOS.
The deterministic owner gate identified three touched owners and verified
executed functional evidence for each at the exact committed head.
Deliberate mutations failed as expected: loader bypass (5 failures, including
the architecture assertion), byte rewriting (19), reversed filename precedence
(2), and the old shell recipe (14). Every mutation was restored before the
passing runs. The Mermaid block was rendered successfully by local
mmdc.Mechanical spec checks passed for ASCII, forbidden tokens, five schemas,
17 fixtures, 124 unique requirement anchors, count sites, links, requirement
citations and the changelog reference. No Mermaid occurs in the spec itself.
The req-lk-023 clauses match the human-agreed revision byte-for-byte after
excluding the relocated non-normative note.
Prior-head GitHub checks at
a9c6e13succeeded after separate human workflowauthorization. Those are not CI evidence for
05dafde1c3; current-head checksand renewed human review must be observed separately.
Scenario Evidence
tests/unit/deps/test_lockfile_conflict_markers.py::test_read_names_the_file_and_a_manual_next_step(regression-trap for #2979)tests/integration/test_install_conflicted_lockfile_e2e.py::test_commands_fail_closed_and_preserve_the_lockfiletests/integration/test_install_conflicted_lockfile_e2e.py::test_dry_run_names_the_conflict_and_preserves_the_filetests/integration/test_install_conflicted_lockfile_e2e.py::test_global_commands_diagnose_only_the_user_lockfiletests/integration/test_install_conflicted_lockfile_e2e.py::test_export_from_ancestor_preserves_the_conflicted_lockfiletests/integration/test_install_conflicted_lockfile_e2e.py::test_manual_restore_allows_retry_without_a_git_mergetests/integration/test_install_conflicted_lockfile_e2e.py::test_best_effort_installed_paths_preserves_conflicted_bytestests/unit/deps/test_lockfile_conflict_markers.pyHow to test
Run normal, frozen, partial and preview installs; expect named manual guidance,
unchanged bytes, exit 1 for installs and exit 0 for preview.
--global;confirm the diagnosed file is the one selected by that invocation.
options; confirm no active Git merge is required.
bash scripts/lint-architecture-boundaries.sh; expect no diagnostics.Co-authored-by: Copilot 223556219+Copilot@users.noreply.github.com